Skip to content

Add multiplayer counter sample#1273

Open
jhuleatt wants to merge 5 commits intodart-launchfrom
multi-counter
Open

Add multiplayer counter sample#1273
jhuleatt wants to merge 5 commits intodart-launchfrom
multi-counter

Conversation

@jhuleatt
Copy link
Copy Markdown
Collaborator

No description provided.

Co-authored-by: kevmoo <kevmoo@google.com>
Co-authored-by: Rody Davis <rody.davis.jr@gmail.com>
@jhuleatt jhuleatt requested review from kevmoo and rodydavis April 17, 2026 13:57
Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a multi-counter application using Flutter, Firebase, and Dart, organized within a workspace containing app, server, and shared packages. Key features include Google authentication, real-time Firestore synchronization for user and global statistics, and a Dart-based Cloud Function for incrementing counters. Feedback focuses on optimizing the server-side counter logic to use atomic increments instead of $O(N)$ aggregations, removing redundant authentication token calls in the client, and improving security by restricting CORS policies. Additionally, suggestions were made to improve navigation performance by moving initialization logic out of the router and ensuring the UI is properly notified of successful operations.

Comment on lines +9 to +19
Future<void> increment(String userId) async {
try {
await _increment(userId);
await _updateGlobalCount();
} catch (e, stack) {
print('Error incrementing counter for user: $userId');
print(e);
print(stack);
rethrow;
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The current implementation updates the global count by performing a full aggregation over the users collection on every increment. This is an $O(N)$ operation that will not scale as the user base grows.

Instead, you should update the global counters within the same transaction as the user's increment using FieldValue.increment. This ensures atomicity and provides $O(1)$ performance. Once implemented, the helper methods _increment and _updateGlobalCount can be removed.

  Future<void> increment(String userId) async {
    try {
      await _firestore.runTransaction<void>((transaction) async {
        final userRef = _firestore.collection(usersCollection).doc(userId);
        final globalRef = _firestore.collection(globalCollection).doc(varsDocument);

        final userSnapshot = await transaction.get(userRef);

        if (!userSnapshot.exists) {
          transaction.set(userRef, {countField: 1});
          transaction.update(globalRef, {
            totalCountField: const FieldValue.increment(1),
            totalUsersField: const FieldValue.increment(1),
          });
        } else {
          transaction.update(userRef, {countField: const FieldValue.increment(1)});
          transaction.update(globalRef, {
            totalCountField: const FieldValue.increment(1),
          });
        }
      });
    } catch (e, stack) {
      print('Error incrementing counter for user: $userId');
      print(e);
      print(stack);
      rethrow;
    }
  }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see what she's saying - but we lose the ability to guarantee we stay in sync!

Comment on lines +82 to +96
final user = FirebaseAuth.instance.currentUser;
if (user == null) {
_responseController.add(
IncrementResponse.failure('User is not authenticated.'),
);
return;
}

final idToken = await user.getIdToken();
if (idToken == null) {
_responseController.add(
IncrementResponse.failure('User is not authenticated.'),
);
return;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The call to user.getIdToken() is redundant. The Firebase Functions HttpsCallable automatically handles authentication tokens when a user is signed in. Additionally, the retrieved idToken is not used in the subsequent call.

Suggested change
final user = FirebaseAuth.instance.currentUser;
if (user == null) {
_responseController.add(
IncrementResponse.failure('User is not authenticated.'),
);
return;
}
final idToken = await user.getIdToken();
if (idToken == null) {
_responseController.add(
IncrementResponse.failure('User is not authenticated.'),
);
return;
}
final user = FirebaseAuth.instance.currentUser;
if (user == null) {
_responseController.add(
IncrementResponse.failure('User is not authenticated.'),
);
return;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not a bad idea, although very unlikely, given the auth setup

}

try {
await incrementHttpsCallable.call<void>();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The _callIncrement method should notify the _responseController when the operation succeeds so the UI can provide feedback to the user (e.g., clearing a loading state or showing a success message).

Suggested change
await incrementHttpsCallable.call<void>();
await incrementHttpsCallable.call<void>();
_responseController.add(IncrementResponse.success());

}

void _handleIncrementResult(_) {
// TODO: handle the result
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This TODO should be addressed. You should handle the result of the increment operation, for example by logging success or updating internal state if needed.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure what to do here. This method no longer does...anything. but it's the target of the stream.

@rodydavis ?


options: const CallableOptions(
// TODO: should be explicit here about the supported hosts
cors: OptionLiteral(['*']),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-medium medium

Using a wildcard * for CORS is insecure as it allows any domain to call this function. It is recommended to specify the actual allowed origins (e.g., your app's hosting URL).

],
redirect: (context, state) async {
// Ensure we capture the redirect result before checking the auth state.
await FirebaseAuth.instance.getRedirectResult();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

getRedirectResult() should typically be called once during app initialization (e.g., in main or a dedicated initialization block), rather than inside the router's redirect function. Calling it here causes it to execute on every navigation, which is inefficient.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe? I haven't tried it...

I think I put it here when fighting the other auth bits... Need to investigate.

jhuleatt and others added 4 commits April 17, 2026 10:32
* docs: add README for Dart multi_counter sample

Adds a README to the Dart/multi_counter directory highlighting the sample was
presented at Cloud Next 2026. Includes a link to the session.
Also updates the root README.md to reference this sample.

Co-authored-by: jhuleatt <3759507+jhuleatt@users.noreply.github.com>

* Apply suggestions from code review

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* Apply suggestions from code review

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

---------

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
…endencies (#1275)

* ci: setup flutter to build pure dart or flutter workspace projects

Co-authored-by: jhuleatt <3759507+jhuleatt@users.noreply.github.com>

* ci: fix unused import in firebase_options.dart causing dart analyze failure

Co-authored-by: jhuleatt <3759507+jhuleatt@users.noreply.github.com>

* ci: fix unused import in firebase_options.dart causing dart analyze failure

Co-authored-by: jhuleatt <3759507+jhuleatt@users.noreply.github.com>

* ci: fix unused import in firebase_options.dart causing dart analyze failure

Co-authored-by: jhuleatt <3759507+jhuleatt@users.noreply.github.com>

* ci: fix dart format exit code on lib/firebase_options.dart

Co-authored-by: jhuleatt <3759507+jhuleatt@users.noreply.github.com>

---------

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
Copy link
Copy Markdown

@kevmoo kevmoo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A whole pile of comments!

Stream<IncrementResponse> get incrementResponseStream =>
_responseController.stream;

// TODO: consider creating shared constants for collection and field names.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we ca ditch this comment

_responseController.stream;

// TODO: consider creating shared constants for collection and field names.
// ...and putting them in the shared package.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ditto

}
}

// TODO: consider making this a nullable-property and disabling
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ditch this comment, too

Comment on lines +82 to +96
final user = FirebaseAuth.instance.currentUser;
if (user == null) {
_responseController.add(
IncrementResponse.failure('User is not authenticated.'),
);
return;
}

final idToken = await user.getIdToken();
if (idToken == null) {
_responseController.add(
IncrementResponse.failure('User is not authenticated.'),
);
return;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not a bad idea, although very unlikely, given the auth setup

}

void _handleIncrementResult(_) {
// TODO: handle the result
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure what to do here. This method no longer does...anything. but it's the target of the stream.

@rodydavis ?

);
} else {
return FirebaseFunctions.instance.httpsCallableFromUrl(
'https://increment-138342796561.us-central1.run.app',
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we just put example.com here or something?

or do we put a throw in here with a TODO to populate it?

const double doubleSpaceSize = spaceSize * 2;

// TODO: Update this to the final example URI
const githubDisplayUrl = 'github.com/kevmoo/next26_demo';
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should likely ditch this and the bits that depend on it for the sample!

],
redirect: (context, state) async {
// Ensure we capture the redirect result before checking the auth state.
await FirebaseAuth.instance.getRedirectResult();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe? I haven't tried it...

I think I put it here when fighting the other auth bits... Need to investigate.

name: incrementCallable,

options: const CallableOptions(
// TODO: should be explicit here about the supported hosts
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The TODO is REAL – hrm... we could update it for the user to update when they know where they are deploying?

Comment on lines +9 to +19
Future<void> increment(String userId) async {
try {
await _increment(userId);
await _updateGlobalCount();
} catch (e, stack) {
print('Error incrementing counter for user: $userId');
print(e);
print(stack);
rethrow;
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see what she's saying - but we lose the ability to guarantee we stay in sync!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants